Implement Structured Console Logging - #83
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughIntroduces optional tree-structured console logging and a runtime feature flag, implements tree-scope state and writers/sink, and wires scoped tree logging across HTTP auth, request execution (including retries), parsing, scripts, and tests; also adds a LoggingSettings.UseTreeLogging flag and ApplicationBuilder.WithLogging parameter. Changes
Sequence Diagram(s)sequenceDiagram
participant Caller as Caller (request / script / test)
participant ILogger as ILogger
participant Ext as TreeLoggingExtensions
participant Store as TreeScopeStateStore
participant Sink as TreeConsoleSink
participant Writer as TreeConsoleWriter
participant Console as Console.Out
Caller->>ILogger: BeginTreeScope()
ILogger->>Ext: BeginTreeScope(this)
Ext->>Store: Push(new ScopeState)
Ext-->>ILogger: IDisposable scope
Caller->>ILogger: LogEvent(event)
ILogger->>Sink: Emit(LogEvent)
Sink->>Store: GetActiveScopes()
alt Unprinted scopes exist
Sink->>Writer: WriteOpening(depth, timestamp, level)
Writer->>Console: write opening lines
Sink->>Store: MarkPrinted(state)
end
Sink->>Console: Format and write event (with indent)
Caller->>ILogger: Dispose scope
ILogger->>Store: Pop()
Sink->>Writer: WriteClosing(depth, timestamp, level)
Writer->>Console: write closing lines
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/TeaPie/Logging/TreeScopeStateStore.cs`:
- Around line 33-48: The Pop method on TreeScopeStateStore can leave stale Depth
values when removing a non-tail ScopeState; either remove the out-of-order
removal branch or make it explicit: assert LIFO disposal and only support
removing the tail (i.e., when list[^1] == state call list.RemoveAt), or if you
need to support middle removals update Depth for all items above the removed
index (recompute Depth for list elements after the removed index). Update the
Pop implementation (referencing Pop, ScopeState, _current.Value, list.RemoveAt
and list.Remove) to enforce one of these behaviors and remove the silent
else-case to avoid incorrect indentation in tree output.
- Line 11: The AsyncLocal currently holds a mutable List<ScopeState> (_current)
which is shared by reference across forked async contexts; replace it with
AsyncLocal<ImmutableStack<ScopeState>?> so each push/pop produces a new
immutable instance and forked contexts stay isolated. Update the declaration of
_current to AsyncLocal<ImmutableStack<ScopeState>?>, add "using
System.Collections.Immutable", and change every place that manipulates
_current.Value (e.g., Push/Pop/Peek or any methods referencing ScopeState or
_current in TreeScopeStateStore) to use ImmutableStack<T>.Push/Pop/TryPeek and
assign the resulting stack back to _current.Value (initializing to
ImmutableStack<ScopeState>.Empty where needed). Ensure null-handling matches
original behavior and remove any in-place mutations of List<ScopeState>.
🧹 Nitpick comments (8)
src/TeaPie/Logging/TreeScope.cs (1)
23-28: Closing bracket always rendered atInformationlevel regardless of scope content.Line 27 hardcodes
LogEventLevel.Informationfor the closing tree line. If the scope contained onlyDebugorWarningmessages, the closing└──header will showINF, creating a visual mismatch with the opening line (which uses the actual event level fromTreeConsoleSink). Consider capturing the level used for the opening line inScopeStateand reusing it here.src/TeaPie/Http/ExecuteRequestStep.cs (2)
57-61: Tree scope around a single log statement adds visual noise without grouping benefit.
BeginTreeScopehere wraps exactly oneLogDebugcall. This produces an opening bracket┌──, the message, and a closing bracket└──for a single line — adding clutter rather than structure. Consider either removing the scope or including additional related log statements within it.
114-128: Duplicated request-sending logic between retry and non-retry paths.Both branches perform identical
GetMessage→Options.Set→SendAsyncsequences. The only difference is the tree scope wrapper. You could extract the common logic and conditionally wrap it.♻️ Suggested refactor
- if (retryAttemptNumber > 0) - { - using (logger.BeginTreeScope()) - { - var retryRequest = GetMessage(requestExecutionContext, originalMessage, content, ref messageUsed); - retryRequest.Options.Set(_contextKey, requestExecutionContext); - return await client.SendAsync(retryRequest, token); - } - } - else - { - var request = GetMessage(requestExecutionContext, originalMessage, content, ref messageUsed); - request.Options.Set(_contextKey, requestExecutionContext); - return await client.SendAsync(request, token); - } + using (retryAttemptNumber > 0 ? logger.BeginTreeScope() : EmptyDisposable.Instance) + { + var msg = GetMessage(requestExecutionContext, originalMessage, content, ref messageUsed); + msg.Options.Set(_contextKey, requestExecutionContext); + return await client.SendAsync(msg, token); + }Note: this requires access to
EmptyDisposableor relying on the fact thatBeginTreeScopealready returns one when tree logging is disabled. An alternative is to always callBeginTreeScopefor retries and let the extension method handle the no-op case.src/TeaPie/Logging/TreeConsoleWriter.cs (1)
11-23: Console writes fromTreeConsoleWriterare not synchronized withTreeConsoleSinkoutput.Both
TreeConsoleSink.Emitand theseWriteOpening/WriteClosingmethods write toConsole.Outindependently. Under concurrent async execution, opening/closing brackets could interleave with log message bodies from the sink. If this becomes an issue, consider routing all tree output through a shared lock or a single writer abstraction.src/TeaPie/Logging/TreeLoggingExtensions.cs (2)
5-11: Static mutable flag without memory barrier — fine for set-once-at-startup, but worth a note.
_treeLoggingEnabledis written once during startup and read on potentially different threads. The current service-configuration-before-use pattern provides an implicit barrier in most DI frameworks, so this works in practice. If the flag ever needs to be toggled at runtime, consider making itvolatileor usingInterlocked.
13-21:loggerparameter is intentionally unused — consider documenting why.Line 15 discards the parameter (
_ = logger). This is a deliberate design choice for API discoverability as an extension method, but it may confuse future maintainers. A brief comment explaining the intent would help.src/TeaPie/Logging/TreeConsoleSink.cs (2)
51-62:MessageTemplateParseris allocated on everyEmitcall.
MessageTemplateParseris stateless and safe to reuse. Promote it to aprivate static readonlyfield to avoid per-message allocation on a hot path.Proposed fix
public class TreeConsoleSink(ITextFormatter formatter) : ILogEventSink { private const string VerticalBar = "│ "; + private static readonly Serilog.Parsing.MessageTemplateParser _parser = new(); private readonly ITextFormatter _formatter = formatter;private static LogEvent AddPrefixToMessage(LogEvent original, string prefix) { - var newMessageTemplate = new Serilog.Parsing.MessageTemplateParser() - .Parse(prefix + original.MessageTemplate.Text); + var newMessageTemplate = _parser.Parse(prefix + original.MessageTemplate.Text);
32-38:printedCountfilter is redundant after the loop above.The loop on lines 22–28 marks every scope as printed, so
stack?.Count(s => s.Printed)will always equalstack.Countat this point. You can simplify:- var printedCount = stack?.Count(s => s.Printed) ?? 0; + var printedCount = stack?.Count ?? 0;
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (4)
src/TeaPie/Logging/TreeLoggingExtensions.cs (1)
13-21:_ = loggerdiscards the extension method receiver without explanation.The
ILoggerparameter exists solely so callers can writelogger.BeginTreeScope()naturally, but the implementation never touches it. A brief doc-comment or a#pragma warning disable IDE0060makes the intent explicit and avoids future readers wondering if a logger reference was accidentally forgotten.♻️ Suggested clarification
+ /// <summary> + /// Begins a tree-structured logging scope. The <paramref name="logger"/> parameter + /// is unused; it exists only to enable fluent call-site syntax. + /// </summary> public static IDisposable BeginTreeScope(this ILogger logger) { - _ = logger; +#pragma warning disable IDE0060 // unused parameter is intentional (extension method receiver) + _ = logger; +#pragma warning restore IDE0060🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeLoggingExtensions.cs` around lines 13 - 21, The extension method BeginTreeScope(ILogger logger) intentionally doesn't use the logger parameter but currently discards it with "_ = logger" which is confusing; update the method to make this explicit by either adding a short XML doc comment on BeginTreeScope explaining the unused receiver is for natural extension-method call syntax, or suppress the unused-parameter warning with "#pragma warning disable IDE0060" (and re-enable after) so readers know the omission is intentional; keep the existing logic referencing _treeLoggingEnabled, returning EmptyDisposable.Instance or new TreeScope() unchanged.src/TeaPie/Logging/TreeScope.cs (1)
27-27: Closing bracket always showsINFregardless of actual scope content.
LogEventLevel.Informationis hardcoded for both the opening (WriteOpeninginTreeConsoleSink) and the closing marker here. If all events in the scope wereDBG, the closing└──will still read[HH:mm:ss INF], which is visually inconsistent. Consider storing the highest level seen within the scope inScopeStateand using it here.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeScope.cs` at line 27, The closing marker currently hardcodes LogEventLevel.Information; add a highest-seen level to the scope state (e.g., a property/field on ScopeState such as HighestLevel or MaxLevel) and ensure all event-recording paths update ScopeState.HighestLevel when an event with a higher severity is observed; then change the call in TreeScope (where TreeConsoleWriter.WriteClosing is invoked) to pass TreeConsoleWriter.LevelToShort(scopeState.HighestLevel) instead of LogEventLevel.Information so the closing bracket reflects the highest level seen in the scope.src/TeaPie/Logging/TreeConsoleSink.cs (2)
32-32:Count(s => s.Printed)is alwaysstack.Countafter the preceding loop.The
foreachabove guarantees every scope in the stack is marked printed before reaching line 32. The LINQ predicate is therefore alwaystruefor every element, making the O(n) enumeration redundant.♻️ Proposed simplification
- var printedCount = stack?.Count(s => s.Printed) ?? 0; + var printedCount = stack?.Count ?? 0;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeConsoleSink.cs` at line 32, The computed printedCount in TreeConsoleSink.cs uses stack?.Count(s => s.Printed) but the preceding foreach already sets every scope's Printed flag, so the predicate is redundant and does an extra O(n) enumeration; update the printedCount assignment in the method containing the loop (referencing the printedCount local and the stack variable) to use stack?.Count ?? 0 (or simply stack.Count when non-nullable) instead of Count(s => s.Printed) to avoid the unnecessary pass.
13-13:VerticalBarconstant is duplicated fromTreeConsoleWriter.
"│ "is already defined inTreeConsoleWriter(referenced inTreeConsoleWriter.cs). Keeping a private copy here risks the two diverging silently if the indentation string is ever changed. Expose it fromTreeConsoleWriter(e.g.,internal const string VerticalBar) and reference it here.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeConsoleSink.cs` at line 13, The VerticalBar constant is duplicated; remove the private const string VerticalBar from TreeConsoleSink and instead reference the single definition on TreeConsoleWriter by making TreeConsoleWriter.VerticalBar an accessible constant (change its declaration to internal const string VerticalBar) and update usages in TreeConsoleSink to use TreeConsoleWriter.VerticalBar so both classes share the same value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/TeaPie/Logging/TreeConsoleSink.cs`:
- Around line 51-62: AddPrefixToMessage is allocating a new
MessageTemplateParser on every call and is using the 5-arg LogEvent constructor
which drops TraceId/SpanId; make a single private static readonly
Serilog.Parsing.MessageTemplateParser instance (reused by AddPrefixToMessage)
and update the LogEvent construction to use the overload that preserves
trace/span (the 7-parameter constructor: timestamp, level, exception,
messageTemplate, properties, traceId, spanId) so original.TraceId and
original.SpanId are passed through when creating the new LogEvent.
In `@src/TeaPie/Logging/TreeScope.cs`:
- Around line 16-31: Dispose currently sets _disposed = true after calling
TreeConsoleWriter.WriteClosing so if WriteClosing throws the instance remains
non-disposed and subsequent Dispose() will re-pop the state and write a
duplicate closing; fix by marking the instance disposed before the
potentially-throwing write or by enclosing the WriteClosing call in a
try/finally that ensures _disposed is set to true regardless; update the Dispose
method (referencing Dispose, _disposed, TreeScopeStateStore.Pop, _state.Printed
and TreeConsoleWriter.WriteClosing) so the state is popped once and _disposed is
set prior to or guaranteed after the WriteClosing call.
---
Nitpick comments:
In `@src/TeaPie/Logging/TreeConsoleSink.cs`:
- Line 32: The computed printedCount in TreeConsoleSink.cs uses stack?.Count(s
=> s.Printed) but the preceding foreach already sets every scope's Printed flag,
so the predicate is redundant and does an extra O(n) enumeration; update the
printedCount assignment in the method containing the loop (referencing the
printedCount local and the stack variable) to use stack?.Count ?? 0 (or simply
stack.Count when non-nullable) instead of Count(s => s.Printed) to avoid the
unnecessary pass.
- Line 13: The VerticalBar constant is duplicated; remove the private const
string VerticalBar from TreeConsoleSink and instead reference the single
definition on TreeConsoleWriter by making TreeConsoleWriter.VerticalBar an
accessible constant (change its declaration to internal const string
VerticalBar) and update usages in TreeConsoleSink to use
TreeConsoleWriter.VerticalBar so both classes share the same value.
In `@src/TeaPie/Logging/TreeLoggingExtensions.cs`:
- Around line 13-21: The extension method BeginTreeScope(ILogger logger)
intentionally doesn't use the logger parameter but currently discards it with "_
= logger" which is confusing; update the method to make this explicit by either
adding a short XML doc comment on BeginTreeScope explaining the unused receiver
is for natural extension-method call syntax, or suppress the unused-parameter
warning with "#pragma warning disable IDE0060" (and re-enable after) so readers
know the omission is intentional; keep the existing logic referencing
_treeLoggingEnabled, returning EmptyDisposable.Instance or new TreeScope()
unchanged.
In `@src/TeaPie/Logging/TreeScope.cs`:
- Line 27: The closing marker currently hardcodes LogEventLevel.Information; add
a highest-seen level to the scope state (e.g., a property/field on ScopeState
such as HighestLevel or MaxLevel) and ensure all event-recording paths update
ScopeState.HighestLevel when an event with a higher severity is observed; then
change the call in TreeScope (where TreeConsoleWriter.WriteClosing is invoked)
to pass TreeConsoleWriter.LevelToShort(scopeState.HighestLevel) instead of
LogEventLevel.Information so the closing bracket reflects the highest level seen
in the scope.
| public void Dispose() | ||
| { | ||
| if (_disposed) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| TreeScopeStateStore.Pop(_state); | ||
|
|
||
| if (_state.Printed) | ||
| { | ||
| TreeConsoleWriter.WriteClosing(_state.Depth, DateTimeOffset.Now, TreeConsoleWriter.LevelToShort(LogEventLevel.Information)); | ||
| } | ||
|
|
||
| _disposed = true; | ||
| } |
There was a problem hiding this comment.
_disposed = true set after WriteClosing — exception leaves it re-entrant.
If TreeConsoleWriter.WriteClosing (which calls Console.Out.WriteLine) throws, _disposed remains false. The state has already been popped from the stack, so the next Dispose() call skips the Pop, finds _state.Printed == true, and emits a second closing bracket.
Move the flag before the write, or wrap the write in a try/finally:
🛡️ Proposed fix
- TreeScopeStateStore.Pop(_state);
-
- if (_state.Printed)
- {
- TreeConsoleWriter.WriteClosing(_state.Depth, DateTimeOffset.Now, TreeConsoleWriter.LevelToShort(LogEventLevel.Information));
- }
-
- _disposed = true;
+ _disposed = true;
+ TreeScopeStateStore.Pop(_state);
+
+ if (_state.Printed)
+ {
+ TreeConsoleWriter.WriteClosing(_state.Depth, DateTimeOffset.Now, TreeConsoleWriter.LevelToShort(LogEventLevel.Information));
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/TeaPie/Logging/TreeScope.cs` around lines 16 - 31, Dispose currently sets
_disposed = true after calling TreeConsoleWriter.WriteClosing so if WriteClosing
throws the instance remains non-disposed and subsequent Dispose() will re-pop
the state and write a duplicate closing; fix by marking the instance disposed
before the potentially-throwing write or by enclosing the WriteClosing call in a
try/finally that ensures _disposed is set to true regardless; update the Dispose
method (referencing Dispose, _disposed, TreeScopeStateStore.Pop, _state.Printed
and TreeConsoleWriter.WriteClosing) so the state is popped once and _disposed is
set prior to or guaranteed after the WriteClosing call.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
src/TeaPie/Logging/TreeScope.cs (1)
16-31:⚠️ Potential issue | 🟡 Minor
_disposed = trueis still set afterWriteClosing— unresolved from previous review.The guard at line 18 is bypassed on a second
Dispose()call ifWriteClosing(line 27) throws:_disposedstaysfalse, so the next call re-enters, callsPop()on an already-modified stack, and may corrupt scope depth for all subsequent/concurrent scopes in the same async context.🛡️ Proposed fix (set flag or use try/finally)
- TreeScopeStateStore.Pop(); - - if (_state.Printed) - { - TreeConsoleWriter.WriteClosing(_state.Depth, DateTimeOffset.Now, TreeConsoleWriter.LevelToShort(LogEventLevel.Information)); - } - - _disposed = true; + _disposed = true; + TreeScopeStateStore.Pop(); + + if (_state.Printed) + { + TreeConsoleWriter.WriteClosing(_state.Depth, DateTimeOffset.Now, TreeConsoleWriter.LevelToShort(LogEventLevel.Information)); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeScope.cs` around lines 16 - 31, Dispose can re-enter if TreeConsoleWriter.WriteClosing throws because _disposed is only set after that call; to fix, set the _disposed flag immediately after the early-return guard (i.e., in Dispose() set _disposed = true right after checking if (_disposed) return) before calling TreeScopeStateStore.Pop() and the WriteClosing logic (or alternatively wrap Pop()/WriteClosing in try/finally and set _disposed in the finally) so that subsequent Dispose() calls won't re-enter and corrupt the TreeScopeStateStore stack; reference Dispose(), _disposed, TreeScopeStateStore.Pop(), TreeConsoleWriter.WriteClosing, _state.Printed and _state.Depth when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/TeaPie/Logging/TreeScope.cs`:
- Line 23: TreeScopeStateStore.Pop() is removing the top ScopeState blindly
which can corrupt Depth if a TreeScope instance is disposed out-of-order; update
Pop() (or TreeScope.Dispose) to validate that the popped ScopeState matches the
expected instance (_state) before removing it and throw a clear
InvalidOperationException on mismatch, or alternatively document and enforce a
strict LIFO contract in TreeScope/TreeScopeStateStore; reference
TreeScope.Dispose (uses TreeScopeStateStore.Pop()), the TreeScope._state field,
and the ScopeState instances when adding this defensive check or explicit
contract note.
---
Duplicate comments:
In `@src/TeaPie/Logging/TreeScope.cs`:
- Around line 16-31: Dispose can re-enter if TreeConsoleWriter.WriteClosing
throws because _disposed is only set after that call; to fix, set the _disposed
flag immediately after the early-return guard (i.e., in Dispose() set _disposed
= true right after checking if (_disposed) return) before calling
TreeScopeStateStore.Pop() and the WriteClosing logic (or alternatively wrap
Pop()/WriteClosing in try/finally and set _disposed in the finally) so that
subsequent Dispose() calls won't re-enter and corrupt the TreeScopeStateStore
stack; reference Dispose(), _disposed, TreeScopeStateStore.Pop(),
TreeConsoleWriter.WriteClosing, _state.Printed and _state.Depth when making the
change.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
src/TeaPie/Logging/TreeConsoleSink.cssrc/TeaPie/Logging/TreeConsoleWriter.cssrc/TeaPie/Logging/TreeScope.cssrc/TeaPie/Logging/TreeScopeStateStore.cs
🚧 Files skipped from review as they are similar to previous changes (3)
- src/TeaPie/Logging/TreeConsoleSink.cs
- src/TeaPie/Logging/TreeScopeStateStore.cs
- src/TeaPie/Logging/TreeConsoleWriter.cs
| return; | ||
| } | ||
|
|
||
| TreeScopeStateStore.Pop(); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -name "TreeScope.cs" -o -name "*TreeScopeStateStore*" | head -20Repository: Kros-sk/TeaPie
Length of output: 135
🏁 Script executed:
cat -n ./src/TeaPie/Logging/TreeScope.csRepository: Kros-sk/TeaPie
Length of output: 954
🏁 Script executed:
cat -n ./src/TeaPie/Logging/TreeScopeStateStore.csRepository: Kros-sk/TeaPie
Length of output: 1432
🏁 Script executed:
rg -n "BeginTreeScope" --type cs -A 3 -B 1Repository: Kros-sk/TeaPie
Length of output: 3064
🏁 Script executed:
rg -n "TreeScope\(\)" --type cs -B 2 -A 2Repository: Kros-sk/TeaPie
Length of output: 3756
🏁 Script executed:
# Search for any non-using patterns with TreeScope or BeginTreeScope that might indicate out-of-order disposal
rg -n "BeginTreeScope\(\)|new TreeScope\(\)" --type cs -B 5 -A 5 | grep -v "using"Repository: Kros-sk/TeaPie
Length of output: 6467
Pop() lacks identity validation—add defensive check or document strict LIFO requirement.
TreeScopeStateStore.Pop() unconditionally removes the top ScopeState without verifying it matches _state. While all current call sites use using statements (enforcing correct LIFO order), this design lacks protection against future misuse. If a TreeScope were disposed out of order—via manual disposal without using or by exception unwinding in nested scopes—the wrong state would be removed, permanently offsetting Depth for subsequent scopes in that async context.
Consider adding a validation check in Pop() to compare the popped state against the expected instance, or document that LIFO ordering is a strict contract for all callers.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/TeaPie/Logging/TreeScope.cs` at line 23, TreeScopeStateStore.Pop() is
removing the top ScopeState blindly which can corrupt Depth if a TreeScope
instance is disposed out-of-order; update Pop() (or TreeScope.Dispose) to
validate that the popped ScopeState matches the expected instance (_state)
before removing it and throw a clear InvalidOperationException on mismatch, or
alternatively document and enforce a strict LIFO contract in
TreeScope/TreeScopeStateStore; reference TreeScope.Dispose (uses
TreeScopeStateStore.Pop()), the TreeScope._state field, and the ScopeState
instances when adding this defensive check or explicit contract note.
There was a problem hiding this comment.
♻️ Duplicate comments (2)
src/TeaPie/Logging/TreeConsoleSink.cs (2)
74-74:publicon a member of aninternalclass — useinternalto align with project policy.The enclosing
TreeConsoleSinkExtensionsis alreadyinternal, sopublichas no effect on the assembly API surface but is inconsistent with the project-wide guideline of not usingpublicunless necessary.♻️ Proposed change
- public static LoggerConfiguration TreeConsole( + internal static LoggerConfiguration TreeConsole( this LoggerSinkConfiguration sinkConfiguration,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeConsoleSink.cs` at line 74, The TreeConsole method is declared public inside the internal TreeConsoleSinkExtensions class; change its accessibility to internal to match the enclosing class and project policy. Locate the TreeConsole method in the TreeConsoleSinkExtensions class and replace the public modifier with internal so the extension method's visibility aligns with the internal class.
58-69:⚠️ Potential issue | 🟠 Major
MessageTemplateParserper-call allocation andTraceId/SpanIdloss still unaddressed.Two previously flagged issues remain:
Parser allocation:
new Serilog.Parsing.MessageTemplateParser()is instantiated on every log event that falls inside a tree scope. It is stateless and should be aprivate static readonlyfield.Trace context loss: The 5-parameter
LogEventconstructor silently dropsTraceIdandSpanId. Serilog exposes a 7-parameter public constructor(timestamp, level, exception, messageTemplate, properties, traceId, spanId)that preserves trace correlation.♻️ Proposed fix
+ private static readonly Serilog.Parsing.MessageTemplateParser _templateParser = new(); private static LogEvent AddPrefixToMessage(LogEvent original, string prefix) { - var newMessageTemplate = new Serilog.Parsing.MessageTemplateParser() - .Parse(prefix + original.MessageTemplate.Text); + var newMessageTemplate = _templateParser + .Parse(prefix + original.MessageTemplate.Text); return new LogEvent( original.Timestamp, original.Level, original.Exception, newMessageTemplate, - original.Properties.Select(kvp => new LogEventProperty(kvp.Key, kvp.Value))); + original.Properties.Select(kvp => new LogEventProperty(kvp.Key, kvp.Value)), + original.TraceId ?? default, + original.SpanId ?? default); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeConsoleSink.cs` around lines 58 - 69, Make MessageTemplateParser a single static instance and use the 7-argument LogEvent constructor to preserve trace correlation: change AddPrefixToMessage to reuse a private static readonly Serilog.Parsing.MessageTemplateParser (instead of newing per call) and call the public LogEvent constructor that accepts (timestamp, level, exception, messageTemplate, properties, traceId, spanId), passing original.TraceId and original.SpanId; also materialize the properties sequence into the expected collection type (e.g., a List<LogEventProperty>) when constructing the new LogEvent so no properties are lost.
🧹 Nitpick comments (3)
src/TeaPie/Logging/TreeConsoleSink.cs (1)
39-43:Count(s => s.Printed)is always equal toscopes.Countat call-site.
PrintUnopenedScopesmarks every scope as printed beforeBuildIndentPrefixis called, so the predicate is alwaystrueand the filteredCountequalsscopes.Count. Usingscopes?.Count ?? 0is simpler and avoids an extra LINQ enumeration.♻️ Proposed simplification
private static string BuildIndentPrefix(IReadOnlyList<TreeScopeStateStore.ScopeState>? scopes) { - var printedCount = scopes?.Count(s => s.Printed) ?? 0; + var printedCount = scopes?.Count ?? 0; return TreeConsoleWriter.BuildPrefix(printedCount); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeConsoleSink.cs` around lines 39 - 43, BuildIndentPrefix currently computes printedCount using scopes?.Count(s => s.Printed) but PrintUnopenedScopes guarantees every scope is marked printed before BuildIndentPrefix is called, so the predicate is redundant; change the computation in BuildIndentPrefix to use scopes?.Count ?? 0 to avoid an unnecessary LINQ enumeration and rely on TreeScopeStateStore.ScopeState already being marked by PrintUnopenedScopes, keeping the call to TreeConsoleWriter.BuildPrefix(printedCount) unchanged.src/TeaPie/Logging/TreeScopeStateStore.cs (2)
30-35:ImmutableStack<T>.Count()is O(n) on everyPush.
ImmutableStack<T>does not expose an O(1)Countproperty;stack.Count()is the LINQ extension method that walks the linked list. For typical logging depths this is negligible, but it can be made O(1) by storing an explicit depth counter alongside the stack.♻️ Optional: O(1) depth tracking
- private static readonly AsyncLocal<ImmutableStack<ScopeState>> _current = new(); + private static readonly AsyncLocal<(ImmutableStack<ScopeState> Stack, int Depth)> _current = new(); internal static void Push(ScopeState state) { - var stack = _current.Value ?? ImmutableStack<ScopeState>.Empty; - state.Depth = stack.Count() + 1; - _current.Value = stack.Push(state); + var (stack, depth) = _current.Value; + stack ??= ImmutableStack<ScopeState>.Empty; + state.Depth = depth + 1; + _current.Value = (stack.Push(state), state.Depth); } internal static void Pop() { - var stack = _current.Value; - if (stack?.IsEmpty != false) + var (stack, depth) = _current.Value; + if (stack == null || stack.IsEmpty) return; - _current.Value = stack.Pop(); + _current.Value = (stack.Pop(), depth - 1); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeScopeStateStore.cs` around lines 30 - 35, The Push method currently calls stack.Count() which is O(n); change the storage from just ImmutableStack<ScopeState> in _current to a small container that holds both the ImmutableStack<ScopeState> and an int depth (e.g. a struct/tuple like (stack, depth)), then in Push use that depth to set ScopeState.Depth = container.depth + 1 and set _current.Value to the new container with stack.Push(state) and depth+1; also update the corresponding Pop/PopIfPresent logic to decrement the depth when popping so the counter stays correct. Ensure you reference and update _current, the Push method, any Pop method, ScopeState.Depth, and ImmutableStack<ScopeState> consistently.
8-13:ScopeStateproperties can beinternalto match the project's visibility policy.Since the enclosing class is
internal,publichas no effect on the assembly API surface, but it conflicts with the project convention of avoiding unnecessarypublicvisibility on members of non-public types.♻️ Proposed change
internal sealed class ScopeState { - public int Depth { get; set; } - public LogEventLevel? PrintedLevel { get; set; } + internal int Depth { get; set; } + internal LogEventLevel? PrintedLevel { get; set; } public bool Printed => PrintedLevel.HasValue; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeScopeStateStore.cs` around lines 8 - 13, The ScopeState class exposes members as public despite the enclosing class being internal; change the member visibility to internal: update the Depth property, the PrintedLevel property, and the Printed computed property in the ScopeState class (class name: ScopeState) from public to internal so they follow the project's convention of not exposing public members on non-public types.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@src/TeaPie/Logging/TreeConsoleSink.cs`:
- Line 74: The TreeConsole method is declared public inside the internal
TreeConsoleSinkExtensions class; change its accessibility to internal to match
the enclosing class and project policy. Locate the TreeConsole method in the
TreeConsoleSinkExtensions class and replace the public modifier with internal so
the extension method's visibility aligns with the internal class.
- Around line 58-69: Make MessageTemplateParser a single static instance and use
the 7-argument LogEvent constructor to preserve trace correlation: change
AddPrefixToMessage to reuse a private static readonly
Serilog.Parsing.MessageTemplateParser (instead of newing per call) and call the
public LogEvent constructor that accepts (timestamp, level, exception,
messageTemplate, properties, traceId, spanId), passing original.TraceId and
original.SpanId; also materialize the properties sequence into the expected
collection type (e.g., a List<LogEventProperty>) when constructing the new
LogEvent so no properties are lost.
---
Nitpick comments:
In `@src/TeaPie/Logging/TreeConsoleSink.cs`:
- Around line 39-43: BuildIndentPrefix currently computes printedCount using
scopes?.Count(s => s.Printed) but PrintUnopenedScopes guarantees every scope is
marked printed before BuildIndentPrefix is called, so the predicate is
redundant; change the computation in BuildIndentPrefix to use scopes?.Count ?? 0
to avoid an unnecessary LINQ enumeration and rely on
TreeScopeStateStore.ScopeState already being marked by PrintUnopenedScopes,
keeping the call to TreeConsoleWriter.BuildPrefix(printedCount) unchanged.
In `@src/TeaPie/Logging/TreeScopeStateStore.cs`:
- Around line 30-35: The Push method currently calls stack.Count() which is
O(n); change the storage from just ImmutableStack<ScopeState> in _current to a
small container that holds both the ImmutableStack<ScopeState> and an int depth
(e.g. a struct/tuple like (stack, depth)), then in Push use that depth to set
ScopeState.Depth = container.depth + 1 and set _current.Value to the new
container with stack.Push(state) and depth+1; also update the corresponding
Pop/PopIfPresent logic to decrement the depth when popping so the counter stays
correct. Ensure you reference and update _current, the Push method, any Pop
method, ScopeState.Depth, and ImmutableStack<ScopeState> consistently.
- Around line 8-13: The ScopeState class exposes members as public despite the
enclosing class being internal; change the member visibility to internal: update
the Depth property, the PrintedLevel property, and the Printed computed property
in the ScopeState class (class name: ScopeState) from public to internal so they
follow the project's convention of not exposing public members on non-public
types.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
src/TeaPie/Logging/TreeConsoleSink.cssrc/TeaPie/Logging/TreeScope.cssrc/TeaPie/Logging/TreeScopeStateStore.cs
🚧 Files skipped from review as they are similar to previous changes (1)
- src/TeaPie/Logging/TreeScope.cs
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/TeaPie/Logging/TreeConsoleWriter.cs (2)
24-32: Consider a lower-allocation prefix builder on the hot path.
Enumerable.Repeat+string.Concatis fine functionally, but this path can allocate more than necessary for frequent logs.Proposed refactor
internal static string BuildPrefix(int repeat) { if (repeat <= 0) { return string.Empty; } - return string.Concat(Enumerable.Repeat(VerticalBar, repeat)); + var buffer = new System.Text.StringBuilder(repeat * VerticalBar.Length); + for (var i = 0; i < repeat; i++) + { + buffer.Append(VerticalBar); + } + return buffer.ToString(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeConsoleWriter.cs` around lines 24 - 32, BuildPrefix currently uses Enumerable.Repeat + string.Concat which allocates extra objects on the hot logging path; replace it with a lower-allocation implementation: if VerticalBar is a single char (e.g. "|" ), return new string(VerticalBar[0], repeat); otherwise pre-allocate a StringBuilder with capacity repeat * VerticalBar.Length and append VerticalBar in a simple for loop and return sb.ToString(); update the BuildPrefix method accordingly to use these branches to minimize allocations.
17-22: Decouple writer output fromConsole.Outfor better testability.Hard-coding global console output makes this helper harder to unit-test and less reusable with redirected sinks.
Proposed refactor
+using System.IO; using Serilog.Events; @@ - internal static void WriteOpening(int depth, DateTimeOffset timestamp, string levelShort) - => WriteLine(StartCorner, depth, timestamp, levelShort); + internal static void WriteOpening(int depth, DateTimeOffset timestamp, string levelShort, TextWriter? writer = null) + => WriteLine(StartCorner, depth, timestamp, levelShort, writer ?? Console.Out); @@ - internal static void WriteClosing(int depth, DateTimeOffset timestamp, string levelShort) - => WriteLine(EndCorner, depth, timestamp, levelShort); + internal static void WriteClosing(int depth, DateTimeOffset timestamp, string levelShort, TextWriter? writer = null) + => WriteLine(EndCorner, depth, timestamp, levelShort, writer ?? Console.Out); @@ - private static void WriteLine(string corner, int depth, DateTimeOffset timestamp, string levelShort) + private static void WriteLine(string corner, int depth, DateTimeOffset timestamp, string levelShort, TextWriter writer) { var prefix = BuildPrefix(depth - 1); var header = BuildHeader(timestamp, levelShort); - Console.Out.WriteLine(header + " " + prefix + corner); + writer.WriteLine($"{header} {prefix}{corner}"); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/TeaPie/Logging/TreeConsoleWriter.cs` around lines 17 - 22, The WriteLine method in TreeConsoleWriter.cs is tightly coupled to Console.Out which prevents injecting testable or redirected outputs; change WriteLine to accept a TextWriter (or an instance field) and use that instead of Console.Out, update callers to pass the desired TextWriter (e.g., Console.Out in production, StringWriter in tests), and keep BuildPrefix and BuildHeader usage intact so only the output sink changes; ensure any static usage of WriteLine is adjusted to supply the writer or make the writer a configurable instance dependency.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/TeaPie/Logging/TreeConsoleWriter.cs`:
- Around line 24-32: BuildPrefix currently uses Enumerable.Repeat +
string.Concat which allocates extra objects on the hot logging path; replace it
with a lower-allocation implementation: if VerticalBar is a single char (e.g.
"|" ), return new string(VerticalBar[0], repeat); otherwise pre-allocate a
StringBuilder with capacity repeat * VerticalBar.Length and append VerticalBar
in a simple for loop and return sb.ToString(); update the BuildPrefix method
accordingly to use these branches to minimize allocations.
- Around line 17-22: The WriteLine method in TreeConsoleWriter.cs is tightly
coupled to Console.Out which prevents injecting testable or redirected outputs;
change WriteLine to accept a TextWriter (or an instance field) and use that
instead of Console.Out, update callers to pass the desired TextWriter (e.g.,
Console.Out in production, StringWriter in tests), and keep BuildPrefix and
BuildHeader usage intact so only the output sink changes; ensure any static
usage of WriteLine is adjusted to supply the writer or make the writer a
configurable instance dependency.
🌟 Key Features
┌─,│,└─) to represent nested operations like HTTP requests, scripts, and test cases.--tree-loggingcommand-line option to enable/disable this mode (disabled by default).TreeConsoleFormatterto color-code different levels of the tree and log levels (e.g.,INF,DBG) for better readability.🏗️ Tiered Scope Architecture
This PR uses a two-tiered system to maintain log readability by nesting implementation details inside structural headers. Each type handles disposal differently:
Micro-Structure (Regular Scopes): Managed via
BeginTreeScope.usingblocks for automatic cleanup within the same method.Macro-Structure (Outer Scopes): Managed via
BeginOuterTreeScope.│) to stay open across different parts of the code until explicitly closed.Code Comparison Example:
Combined Visual Result:
🛠️ Technical Implementation
BeginTreeScope()(viaAsyncLocal<ImmutableStack<TreeScope>>) to track the nesting depth of operations safely across threads.TreeConsoleSinkandTreeConsoleWriterfor Serilog to intercept and format log events based on their combinedOuterDepthand stack depth.📊 Example Output
--verbose--log-level information